原理 代码生成器的原理就是:数据 + 模板 => 文件
。
数据
一般为数据库的表字段结构。
模板
的语法与使用的模板引擎有关。
使用模板引擎将数据
和模板
进行编译,编译后的内容输出到文件中就得到了一份代码文件。
功能 因为这个代码生成器是要集成到一个小工具 lazy-mock 内,这个工具的主要功能是启动一个 mock server 服务,包含curd功能,并且支持数据的持久化,文件变化的时候自动重启服务以最新的代码提供 api mock 服务。
代码生成器的功能就是根据配置的数据和模板,编译后将内容输出到指定的目录文件中。因为添加了新的文件,mock server 服务会自动重启。
还要支持模板的定制与开发,以及使用 CLI 安装模板。
可以开发前端项目的模板,直接将编译后的内容输出到前端项目的相关目录下,webpack 的热更新功能也会起作用。
模板引擎 模板引擎使用的是 nunjucks 。
lazy-mock 使用的构建工具是 gulp,使用 gulp-nodemon 实现 mock-server 服务的自动重启。所以这里使用 gulp-nunjucks-render 配合 gulp 的构建流程。
代码生成 编写一个 gulp task :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 const rename = require ('gulp-rename' )const nunjucksRender = require ('gulp-nunjucks-render' )const codeGenerate = require ('./templates/generate' )const ServerFullPath = require ('./package.json' ).ServerFullPath; const FrontendFullPath = require ('./package.json' ).FrontendFullPath; const nunjucksRenderConfig = { path: 'templates/server' , envOptions: { tags: { blockStart: '<%' , blockEnd: '%>' , variableStart: '<$' , variableEnd: '$>' , commentStart: '<#' , commentEnd: '#>' }, }, ext: '.js' , ServerFullPath, FrontendFullPath } gulp.task('code' , function ( ) { require ('events' ).EventEmitter.defaultMaxListeners = 0 return codeGenerate(gulp, nunjucksRender, rename, nunjucksRenderConfig) });
代码具体结构细节可以打开 lazy-mock 进行参照
为了支持模板的开发,以及更灵活的配置,我将代码生成的逻辑全都放在模板目录中。
templates
是存放模板以及数据配置的目录。结构如下:
只生成 lazy-mock 代码的模板中 :
generate.js
的内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 const path = require ('path' )const CodeGenerateConfig = require ('./config' ).default;const Model = CodeGenerateConfig.model;module .exports = function generate (gulp, nunjucksRender, rename, nunjucksRenderConfig ) { nunjucksRenderConfig.data = { model: CodeGenerateConfig.model, config: CodeGenerateConfig.config } const ServerProjectRootPath = nunjucksRenderConfig.ServerFullPath; const serverTemplatePath = 'templates/server/' gulp.src(`${serverTemplatePath} controller.njk` ) .pipe(nunjucksRender(nunjucksRenderConfig)) .pipe(rename(Model.name + '.js' )) .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.ControllerRelativePath)); gulp.src(`${serverTemplatePath} service.njk` ) .pipe(nunjucksRender(nunjucksRenderConfig)) .pipe(rename(Model.name + 'Service.js' )) .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.ServiceRelativePath)); gulp.src(`${serverTemplatePath} model.njk` ) .pipe(nunjucksRender(nunjucksRenderConfig)) .pipe(rename(Model.name + 'Model.js' )) .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.ModelRelativePath)); gulp.src(`${serverTemplatePath} db.njk` ) .pipe(nunjucksRender(nunjucksRenderConfig)) .pipe(rename(Model.name + '_db.json' )) .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.DBRelativePath)); return gulp.src(`${serverTemplatePath} route.njk` ) .pipe(nunjucksRender(nunjucksRenderConfig)) .pipe(rename(Model.name + 'Route.js' )) .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.RouteRelativePath)); }
类似:
1 2 3 4 gulp.src(`${serverTemplatePath} controller.njk` ) .pipe(nunjucksRender(nunjucksRenderConfig)) .pipe(rename(Model.name + '.js' )) .pipe(gulp.dest(ServerProjectRootPath + CodeGenerateConfig.config.ControllerRelativePath));
表示使用 controller.njk 作为模板,nunjucksRenderConfig作为数据(模板内可以获取到 nunjucksRenderConfig 属性 data 上的数据)。编译后进行文件重命名,并保存到指定目录下。
model.js
的内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 var shortid = require ('shortid' )var Mock = require ('mockjs' )var Random = Mock.Randomexport default { name: "book" , Name: "Book" , properties: [ { key: "id" , title: "id" }, { key: "name" , title: "书名" }, { key: "author" , title: "作者" }, { key: "press" , title: "出版社" } ], buildMockData: function ( ) { let data = [] for (let i = 0 ; i < 100 ; i++) { data.push({ id: shortid.generate(), name: Random.cword(5 , 7 ), author: Random.cname(), press: Random.cword(5 , 7 ) }) } return data } }
模板中使用最多的就是这个数据,也是生成新代码需要配置的地方,比如这里配置的是 book ,生成的就是关于 book 的curd 的 mock 服务。要生成别的,修改后执行生成命令即可。
buildMockData 函数的作用是生成 mock 服务需要的随机数据,在 db.njk 模板中会使用:
1 2 3 { "<$ model.name $>":<% if model.buildMockData %><$ model.buildMockData()|dump|safe $><% else %>[]<% endif %> }
这也是 nunjucks 如何在模板中执行函数
config.js
的内容如下:
1 2 3 4 5 6 7 8 export default { RouteRelativePath: '/src/routes/' , ControllerRelativePath: '/src/controllers/' , ServiceRelativePath: '/src/services/' , ModelRelativePath: '/src/models/' , DBRelativePath: '/src/db/' }
配置相应的模板编译后保存的位置。
config/index.js
的内容如下:
1 2 3 4 5 6 import model from './model' ;import config from './config' ;export default { model, config }
针对 lazy-mock 的代码生成的功能就已经完成了,要实现模板的定制直接修改模板文件即可,比如要修改 mock server 服务 api 的接口定义,直接修改 route.njk 文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 import KoaRouter from 'koa-router' import controllers from '../controllers/index.js' import PermissionCheck from '../middleware/PermissionCheck' const router = new KoaRouter()router .get('/<$ model.name $>/paged' , controllers.<$model.name $>.get<$ model.Name $>PagedList) .get('/<$ model.name $>/:id' , controllers.<$ model.name $>.get<$ model.Name $>) .del('/<$ model.name $>/del' , controllers.<$ model.name $>.del<$ model.Name $>) .del('/<$ model.name $>/batchdel' , controllers.<$ model.name $>.del<$ model.Name $>s) .post('/<$ model.name $>/save' , controllers.<$ model.name $>.save<$ model.Name $>) module .exports = router
模板开发与安装 不同的项目,代码结构是不一样的,每次直接修改模板文件会很麻烦。
需要提供这样的功能:针对不同的项目开发一套独立的模板,支持模板的安装。
代码生成的相关逻辑都在模板目录的文件中,模板开发没有什么规则限制,只要保证目录名为 templates
,generate.js
中导出generate
函数即可。
模板的安装原理就是将模板目录中的文件全部覆盖掉即可。不过具体的安装分为本地安装与在线安装。
之前已经说了,这个代码生成器是集成在 lazy-mock 中的,我的做法是在初始化一个新 lazy-mock 项目的时候,指定使用相应的模板进行初始化,也就是安装相应的模板。
使用 Node.js 写了一个 CLI 工具 lazy-mock-cli ,已发到 npm ,其功能包含下载指定的远程模板来初始化新的 lazy-mock 项目。代码参考( copy )了 vue-cli2 。代码不难,说下某些关键点。
安装 CLI 工具:
1 npm install lazy-mock -g
使用模板初始化项目:
1 lazy-mock init d2-admin-pm my-project
d2-admin-pm 是我为一个前端项目 已经写好的一个模板。
init
命令调用的是 lazy-mock-init.js 中的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 #!/usr/bin/env node const download = require ('download-git-repo' )const program = require ('commander' )const ora = require ('ora' )const exists = require ('fs' ).existsSyncconst rm = require ('rimraf' ).syncconst path = require ('path' )const chalk = require ('chalk' )const inquirer = require ('inquirer' )const home = require ('user-home' )const fse = require ('fs-extra' )const tildify = require ('tildify' )const cliSpinners = require ('cli-spinners' );const logger = require ('../lib/logger' )const localPath = require ('../lib/local-path' )const isLocalPath = localPath.isLocalPathconst getTemplatePath = localPath.getTemplatePathprogram.usage('<template-name> [project-name]' ) .option('-c, --clone' , 'use git clone' ) .option('--offline' , 'use cached template' ) program.on('--help' , () => { console .log(' Examples:' ) console .log() console .log(chalk.gray(' # create a new project with an official template' )) console .log(' $ lazy-mock init d2-admin-pm my-project' ) console .log() console .log(chalk.gray(' # create a new project straight from a github template' )) console .log(' $ vue init username/repo my-project' ) console .log() }) function help ( ) { program.parse(process.argv) if (program.args.length < 1 ) return program.help() } help() let template = program.args[0 ]const hasSlash = template.indexOf('/' ) > -1 const rawName = program.args[1 ]const inPlace = !rawName || rawName === '.' const name = inPlace ? path.relative('../' , process.cwd()) : rawNameconst to = path.resolve(rawName || '.' )const clone = program.clone || false const serverTmp = path.join(home, '.lazy-mock' , 'sever' )const tmp = path.join(home, '.lazy-mock' , 'templates' , template.replace(/[\/:]/g , '-' ))if (program.offline) { console .log(`> Use cached template at ${chalk.yellow(tildify(tmp))} ` ) template = tmp } if (inPlace || exists(to)) { inquirer.prompt([{ type: 'confirm' , message: inPlace ? 'Generate project in current directory?' : 'Target directory exists. Continue?' , name: 'ok' }]).then(answers => { if (answers.ok) { run() } }).catch(logger.fatal) } else { run() } function run ( ) { if (isLocalPath(template)) { const templatePath = getTemplatePath(template) if (exists(templatePath)) { generate(name, templatePath, to, err => { if (err) logger.fatal(err) console .log() logger.success('Generated "%s"' , name) }) } else { logger.fatal('Local template "%s" not found.' , template) } } else { if (!hasSlash) { const officialTemplate = 'lazy-mock-templates/' + template downloadAndGenerate(officialTemplate) } else { downloadAndGenerate(template) } } } function downloadAndGenerate (template ) { downloadServer(() => { downloadTemplate(template) }) } function downloadServer (done ) { const spinner = ora('downloading server' ) spinner.spinner = cliSpinners.bouncingBall spinner.start() if (exists(serverTmp)) rm(serverTmp) download('wjkang/lazy-mock' , serverTmp, { clone }, err => { spinner.stop() if (err) logger.fatal('Failed to download server ' + template + ': ' + err.message.trim()) done() }) } function downloadTemplate (template ) { const spinner = ora('downloading template' ) spinner.spinner = cliSpinners.bouncingBall spinner.start() if (exists(tmp)) rm(tmp) download(template, tmp, { clone }, err => { spinner.stop() if (err) logger.fatal('Failed to download template ' + template + ': ' + err.message.trim()) generate(name, tmp, to, err => { if (err) logger.fatal(err) console .log() logger.success('Generated "%s"' , name) }) }) } function generate (name, src, dest, done ) { try { fse.removeSync(path.join(serverTmp, 'templates' )) const packageObj = fse.readJsonSync(path.join(serverTmp, 'package.json' )) packageObj.name = name packageObj.author = "" packageObj.description = "" packageObj.ServerFullPath = path.join(dest) packageObj.FrontendFullPath = path.join(dest, "front-page" ) fse.writeJsonSync(path.join(serverTmp, 'package.json' ), packageObj, { spaces : 2 }) fse.copySync(serverTmp, dest) fse.copySync(path.join(src, 'templates' ), path.join(dest, 'templates' )) } catch (err) { done(err) return } done() }
判断了是使用本地缓存的模板还是拉取最新的模板,拉取线上模板时是从官方仓库拉取还是从别的仓库拉取。
一些小问题 目前代码生成的相关数据并不是来源于数据库,而是在 model.js
中简单配置的,原因是我认为一个 mock server 不需要数据库,lazy-mock 确实如此。
但是如果写一个正儿八经的代码生成器,那肯定是需要根据已经设计好的数据库表来生成代码的。那么就需要连接数据库,读取数据表的字段信息,比如字段名称,字段类型,字段描述等。而不同关系型数据库,读取表字段信息的 sql 是不一样的,所以还要写一堆balabala的判断。可以使用现成的工具 sequelize-auto , 把它读取的 model 数据转成我们需要的格式即可。
生成前端项目代码的时候,会遇到这种情况:
某个目录结构是这样的:
index.js
的内容:
1 2 3 4 5 6 7 8 9 import layoutHeaderAside from '@/layout/header-aside' export default { "layoutHeaderAside" : layoutHeaderAside, "menu" : () => import ('@/pages/sys/menu' ), "route" : () => import ('@/pages/sys/route' ), "role" : () => import ('@/pages/sys/role' ), "user" : () => import ('@/pages/sys/user' ), "interface" : () => import ('@/pages/sys/interface' ) }
如果添加一个 book 就需要在这里加上"book": () => import(/* webpackChunkName: "book" */'@/pages/sys/book')
这一行内容也是可以通过配置模板来生成的,比如模板内容为:
1 "<$ model.name $>": () => import(/* webpackChunkName: "<$ model.name $>" */'@/pages<$ model.module $><$ model.name $>')
但是生成的内容怎么加到index.js
中呢?
第一种方法:复制粘贴
第二种方法:
这部分的模板为 routerMapComponent.njk :
1 2 3 export default { "<$ model.name $>": () => import(/* webpackChunkName: "<$ model.name $>" */'@/pages<$ model.module $><$ model.name $>') }
编译后文件保存到 routerMapComponents 目录下,比如 book.js
修改 index.js :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const files = require .context('./' , true , /\.js$/);import layoutHeaderAside from '@/layout/header-aside' let componentMaps = { "layoutHeaderAside" : layoutHeaderAside, "menu" : () => import ('@/pages/sys/menu' ), "route" : () => import ('@/pages/sys/route' ), "role" : () => import ('@/pages/sys/role' ), "user" : () => import ('@/pages/sys/user' ), "interface" : () => import ('@/pages/sys/interface' ), } files.keys().forEach((key ) => { if (key === './index.js' ) return Object .assign(componentMaps, files(key).default) }) export default componentMaps
使用了 require.context
我目前也是使用了这种方法
第三种方法:
开发模板的时候,做特殊处理,读取原有 index.js 的内容,按行进行分割,在数组的最后一个元素之前插入新生成的内容,注意逗号的处理,将新数组内容重新写入 index.js 中,注意换行。